| @@ -1,9 +1,9 @@ | ||
| 1 | -require 'open3' | |
| 2 | - | |
| 3 | 1 | module Agents | 
| 4 | 2 | class ShellCommandAgent < Agent | 
| 5 | 3 | default_schedule "never" | 
| 6 | 4 |  | 
| 5 | + can_dry_run! | |
| 6 | + | |
| 7 | 7 | def self.should_run? | 
| 8 | 8 | ENV['ENABLE_INSECURE_AGENTS'] == "true" | 
| 9 | 9 | end | 
| @@ -11,7 +11,7 @@ module Agents | ||
| 11 | 11 | description <<-MD | 
| 12 | 12 | The Shell Command Agent will execute commands on your local system, returning the output. | 
| 13 | 13 |  | 
| 14 | - `command` specifies the command to be executed, and `path` will tell ShellCommandAgent in what directory to run this command. | |
| 14 | + `command` specifies the command (either a shell command line string or an array of command line arguments) to be executed, and `path` will tell ShellCommandAgent in what directory to run this command. The content of `stdin` will be fed to the command via the standard input. | |
| 15 | 15 |  | 
| 16 | 16 | `expected_update_period_in_days` is used to determine if the Agent is working. | 
| 17 | 17 |  | 
| @@ -20,6 +20,10 @@ module Agents | ||
| 20 | 20 |  | 
| 21 | 21 | The resulting event will contain the `command` which was executed, the `path` it was executed under, the `exit_status` of the command, the `errors`, and the actual `output`. ShellCommandAgent will not log an error if the result implies that something went wrong. | 
| 22 | 22 |  | 
| 23 | + If `suppress_on_failure` is set to true, no event is emitted when `exit_status` is not zero. | |
| 24 | + | |
| 25 | + If `suppress_on_empty_output` is set to true, no event is emitted when `output` is empty. | |
| 26 | + | |
| 23 | 27 |        *Warning*: This type of Agent runs arbitrary commands on your system, #{Agents::ShellCommandAgent.should_run? ? "but is **currently enabled**" : "and is **currently disabled**"}. | 
| 24 | 28 | Only enable this Agent if you trust everyone using your Huginn installation. | 
| 25 | 29 | You can enable this Agent in your .env file by setting `ENABLE_INSECURE_AGENTS` to `true`. | 
| @@ -31,7 +35,7 @@ module Agents | ||
| 31 | 35 |          { | 
| 32 | 36 | "command": "pwd", | 
| 33 | 37 | "path": "/home/Huginn", | 
| 34 | - "exit_status": "0", | |
| 38 | + "exit_status": 0, | |
| 35 | 39 | "errors": "", | 
| 36 | 40 | "output": "/home/Huginn" | 
| 37 | 41 | } | 
| @@ -41,6 +45,8 @@ module Agents | ||
| 41 | 45 |        { | 
| 42 | 46 | 'path' => "/", | 
| 43 | 47 | 'command' => "pwd", | 
| 48 | + 'suppress_on_failure' => false, | |
| 49 | + 'suppress_on_empty_output' => false, | |
| 44 | 50 | 'expected_update_period_in_days' => 1 | 
| 45 | 51 | } | 
| 46 | 52 | end | 
| @@ -50,6 +56,16 @@ module Agents | ||
| 50 | 56 | errors.add(:base, "The path, command, and expected_update_period_in_days fields are all required.") | 
| 51 | 57 | end | 
| 52 | 58 |  | 
| 59 | + case options['stdin'] | |
| 60 | + when String, nil | |
| 61 | + else | |
| 62 | + errors.add(:base, "stdin must be a string.") | |
| 63 | + end | |
| 64 | + | |
| 65 | +      unless Array(options['command']).all? { |o| o.is_a?(String) } | |
| 66 | + errors.add(:base, "command must be a shell command line string or an array of command line arguments.") | |
| 67 | + end | |
| 68 | + | |
| 53 | 69 | unless File.directory?(options['path']) | 
| 54 | 70 |          errors.add(:base, "#{options['path']} is not a real directory.") | 
| 55 | 71 | end | 
| @@ -75,38 +91,62 @@ module Agents | ||
| 75 | 91 | if Agents::ShellCommandAgent.should_run? | 
| 76 | 92 | command = opts['command'] | 
| 77 | 93 | path = opts['path'] | 
| 94 | + stdin = opts['stdin'] | |
| 95 | + | |
| 96 | + result, errors, exit_status = run_command(path, command, stdin) | |
| 78 | 97 |  | 
| 79 | - result, errors, exit_status = run_command(path, command) | |
| 98 | +        payload = { | |
| 99 | + 'command' => command, | |
| 100 | + 'path' => path, | |
| 101 | + 'exit_status' => exit_status, | |
| 102 | + 'errors' => errors, | |
| 103 | + 'output' => result, | |
| 104 | + } | |
| 80 | 105 |  | 
| 81 | -        vals = {"command" => command, "path" => path, "exit_status" => exit_status, "errors" => errors, "output" => result} | |
| 82 | - created_event = create_event :payload => vals | |
| 106 | + unless suppress_event?(payload) | |
| 107 | + created_event = create_event payload: payload | |
| 108 | + end | |
| 83 | 109 |  | 
| 84 | -        log("Ran '#{command}' under '#{path}'", :outbound_event => created_event, :inbound_event => event) | |
| 110 | +        log("Ran '#{command}' under '#{path}'", outbound_event: created_event, inbound_event: event) | |
| 85 | 111 | else | 
| 86 | 112 |          log("Unable to run because insecure agents are not enabled.  Edit ENABLE_INSECURE_AGENTS in the Huginn .env configuration.") | 
| 87 | 113 | end | 
| 88 | 114 | end | 
| 89 | 115 |  | 
| 90 | - def run_command(path, command) | |
| 91 | - result = nil | |
| 92 | - errors = nil | |
| 93 | - exit_status = nil | |
| 94 | - | |
| 95 | -      Dir.chdir(path){ | |
| 96 | - begin | |
| 97 | - stdin, stdout, stderr, wait_thr = Open3.popen3(command) | |
| 98 | - exit_status = wait_thr.value.to_i | |
| 99 | - result = stdout.gets(nil) | |
| 100 | - errors = stderr.gets(nil) | |
| 101 | - rescue Exception => e | |
| 102 | - errors = e.to_s | |
| 116 | + def run_command(path, command, stdin) | |
| 117 | + begin | |
| 118 | + rout, wout = IO.pipe | |
| 119 | + rerr, werr = IO.pipe | |
| 120 | + rin, win = IO.pipe | |
| 121 | + | |
| 122 | + pid = spawn(*command, chdir: path, out: wout, err: werr, in: rin) | |
| 123 | + | |
| 124 | + wout.close | |
| 125 | + werr.close | |
| 126 | + rin.close | |
| 127 | + | |
| 128 | + if stdin | |
| 129 | + win.write stdin | |
| 130 | + win.close | |
| 103 | 131 | end | 
| 104 | - } | |
| 105 | 132 |  | 
| 106 | - result = result.to_s.strip | |
| 107 | - errors = errors.to_s.strip | |
| 133 | + (result = rout.read).strip! | |
| 134 | + (errors = rerr.read).strip! | |
| 135 | + | |
| 136 | + _, status = Process.wait2(pid) | |
| 137 | + exit_status = status.exitstatus | |
| 138 | + rescue => e | |
| 139 | + errors = e.to_s | |
| 140 | + result = ''.freeze | |
| 141 | + exit_status = nil | |
| 142 | + end | |
| 108 | 143 |  | 
| 109 | 144 | [result, errors, exit_status] | 
| 110 | 145 | end | 
| 146 | + | |
| 147 | + def suppress_event?(payload) | |
| 148 | + (boolify(interpolated['suppress_on_failure']) && payload['exit_status'].nonzero?) || | |
| 149 | + (boolify(interpolated['suppress_on_empty_output']) && payload['output'].empty?) | |
| 150 | + end | |
| 111 | 151 | end | 
| 112 | 152 | end | 
| @@ -5,19 +5,31 @@ describe Agents::ShellCommandAgent do | ||
| 5 | 5 | @valid_path = Dir.pwd | 
| 6 | 6 |  | 
| 7 | 7 |      @valid_params = { | 
| 8 | - :path => @valid_path, | |
| 9 | - :command => "pwd", | |
| 10 | - :expected_update_period_in_days => "1", | |
| 11 | - } | |
| 8 | + path: @valid_path, | |
| 9 | + command: 'pwd', | |
| 10 | + expected_update_period_in_days: '1', | |
| 11 | + } | |
| 12 | + | |
| 13 | +    @valid_params2 = { | |
| 14 | + path: @valid_path, | |
| 15 | +      command: [RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"'], | |
| 16 | +      stdin: "{{name}}", | |
| 17 | + expected_update_period_in_days: '1', | |
| 18 | + } | |
| 12 | 19 |  | 
| 13 | - @checker = Agents::ShellCommandAgent.new(:name => "somename", :options => @valid_params) | |
| 20 | + @checker = Agents::ShellCommandAgent.new(name: 'somename', options: @valid_params) | |
| 14 | 21 | @checker.user = users(:jane) | 
| 15 | 22 | @checker.save! | 
| 16 | 23 |  | 
| 24 | + @checker2 = Agents::ShellCommandAgent.new(name: 'somename2', options: @valid_params2) | |
| 25 | + @checker2.user = users(:jane) | |
| 26 | + @checker2.save! | |
| 27 | + | |
| 17 | 28 | @event = Event.new | 
| 18 | 29 | @event.agent = agents(:jane_weather_agent) | 
| 19 | 30 |      @event.payload = { | 
| 20 | - :cmd => "ls" | |
| 31 | + 'name' => 'Huginn', | |
| 32 | + 'cmd' => 'ls', | |
| 21 | 33 | } | 
| 22 | 34 | @event.save! | 
| 23 | 35 |  | 
| @@ -27,6 +39,7 @@ describe Agents::ShellCommandAgent do | ||
| 27 | 39 | describe "validation" do | 
| 28 | 40 | before do | 
| 29 | 41 | expect(@checker).to be_valid | 
| 42 | + expect(@checker2).to be_valid | |
| 30 | 43 | end | 
| 31 | 44 |  | 
| 32 | 45 | it "should validate presence of necessary fields" do | 
| @@ -47,7 +60,7 @@ describe Agents::ShellCommandAgent do | ||
| 47 | 60 |  | 
| 48 | 61 | describe "#working?" do | 
| 49 | 62 | it "generating events as scheduled" do | 
| 50 | -      stub(@checker).run_command(@valid_path, 'pwd') { ["fake pwd output", "", 0] } | |
| 63 | +      stub(@checker).run_command(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] } | |
| 51 | 64 |  | 
| 52 | 65 | expect(@checker).not_to be_working | 
| 53 | 66 | @checker.check | 
| @@ -60,7 +73,9 @@ describe Agents::ShellCommandAgent do | ||
| 60 | 73 |  | 
| 61 | 74 | describe "#check" do | 
| 62 | 75 | before do | 
| 63 | -      stub(@checker).run_command(@valid_path, 'pwd') { ["fake pwd output", "", 0] } | |
| 76 | +      stub(@checker).run_command(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] } | |
| 77 | +      stub(@checker).run_command(@valid_path, 'empty_output', nil) { ["", "", 0] } | |
| 78 | +      stub(@checker).run_command(@valid_path, 'failure', nil) { ["failed", "error message", 1] } | |
| 64 | 79 | end | 
| 65 | 80 |  | 
| 66 | 81 | it "should create an event when checking" do | 
| @@ -70,6 +85,42 @@ describe Agents::ShellCommandAgent do | ||
| 70 | 85 |        expect(Event.last.payload[:output]).to eq("fake pwd output") | 
| 71 | 86 | end | 
| 72 | 87 |  | 
| 88 | + it "should create an event when checking (unstubbed)" do | |
| 89 | +      expect { @checker2.check }.to change { Event.count }.by(1) | |
| 90 | + expect(Event.last.payload[:path]).to eq(@valid_path) | |
| 91 | +      expect(Event.last.payload[:command]).to eq([RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"']) | |
| 92 | +      expect(Event.last.payload[:output]).to eq('hello, world.') | |
| 93 | +      expect(Event.last.payload[:errors]).to eq('warning!') | |
| 94 | + end | |
| 95 | + | |
| 96 | + describe "with suppress_on_empty_output" do | |
| 97 | + it "should suppress events on empty output" do | |
| 98 | + @checker.options[:suppress_on_empty_output] = true | |
| 99 | + @checker.options[:command] = 'empty_output' | |
| 100 | +        expect { @checker.check }.not_to change { Event.count } | |
| 101 | + end | |
| 102 | + | |
| 103 | + it "should not suppress events on non-empty output" do | |
| 104 | + @checker.options[:suppress_on_empty_output] = true | |
| 105 | + @checker.options[:command] = 'failure' | |
| 106 | +        expect { @checker.check }.to change { Event.count }.by(1) | |
| 107 | + end | |
| 108 | + end | |
| 109 | + | |
| 110 | + describe "with suppress_on_failure" do | |
| 111 | + it "should suppress events on failure" do | |
| 112 | + @checker.options[:suppress_on_failure] = true | |
| 113 | + @checker.options[:command] = 'failure' | |
| 114 | +        expect { @checker.check }.not_to change { Event.count } | |
| 115 | + end | |
| 116 | + | |
| 117 | + it "should not suppress events on success" do | |
| 118 | + @checker.options[:suppress_on_failure] = true | |
| 119 | + @checker.options[:command] = 'empty_output' | |
| 120 | +        expect { @checker.check }.to change { Event.count }.by(1) | |
| 121 | + end | |
| 122 | + end | |
| 123 | + | |
| 73 | 124 | it "does not run when should_run? is false" do | 
| 74 | 125 |        stub(Agents::ShellCommandAgent).should_run? { false } | 
| 75 | 126 |        expect { @checker.check }.not_to change { Event.count } | 
| @@ -78,7 +129,7 @@ describe Agents::ShellCommandAgent do | ||
| 78 | 129 |  | 
| 79 | 130 | describe "#receive" do | 
| 80 | 131 | before do | 
| 81 | -      stub(@checker).run_command(@valid_path, @event.payload[:cmd]) { ["fake ls output", "", 0] } | |
| 132 | +      stub(@checker).run_command(@valid_path, @event.payload[:cmd], nil) { ["fake ls output", "", 0] } | |
| 82 | 133 | end | 
| 83 | 134 |  | 
| 84 | 135 | it "creates events" do | 
| @@ -89,6 +140,13 @@ describe Agents::ShellCommandAgent do | ||
| 89 | 140 |        expect(Event.last.payload[:output]).to eq("fake ls output") | 
| 90 | 141 | end | 
| 91 | 142 |  | 
| 143 | + it "creates events (unstubbed)" do | |
| 144 | + @checker2.receive([@event]) | |
| 145 | + expect(Event.last.payload[:path]).to eq(@valid_path) | |
| 146 | +      expect(Event.last.payload[:output]).to eq('hello, Huginn.') | |
| 147 | +      expect(Event.last.payload[:errors]).to eq('warning!') | |
| 148 | + end | |
| 149 | + | |
| 92 | 150 | it "does not run when should_run? is false" do | 
| 93 | 151 |        stub(Agents::ShellCommandAgent).should_run? { false } | 
| 94 | 152 |  |